Skip to content

perf: reuse one StatisticsContext across ensure_distribution - #25098

Open
zhuqi-lucas wants to merge 3 commits into
apache:mainfrom
zhuqi-lucas:qizhu/ensure-distribution-stats-memoization
Open

perf: reuse one StatisticsContext across ensure_distribution#25098
zhuqi-lucas wants to merge 3 commits into
apache:mainfrom
zhuqi-lucas:qizhu/ensure-distribution-stats-memoization

Conversation

@zhuqi-lucas

@zhuqi-lucas zhuqi-lucas commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

None filed; small self-contained perf fix. Rationale below.

Rationale for this change

get_repartition_requirement_status creates a fresh StatisticsContext::new() once per child. StatisticsContext::compute recurses the child's whole subtree and carries a pointer-keyed memoization cache its own docstring describes as a "per-call memoization cache" meant to be reused across a walk. Allocating a new context per child discards that cache every time, so a single ensure_distribution pass recomputes shared subtree statistics O(depth) times.

On a deep/wide plan this is measurable. In our deployment (EnsureRequirements runs several times over a ~200-node plan) sharing the cache cut physical planning by ~10% with no plan change.

What changes are included in this PR?

  • Thread one StatisticsContext through the ensure_distribution transform_up (pass &StatisticsContext into get_repartition_requirement_status) so each subtree's statistics are computed once per pass.
  • StatsCache is keyed by raw node pointer, and ensure_distribution returns Transformed::yes unconditionally, so the cache reset is keyed on whether the node's plan pointer actually changed (Arc::ptr_eq before/after). A node that changed may have freed a cached child (which would make a stale pointer key unsafe); a node that made no change cannot, so the cache safely persists across the no-op nodes that dominate a deep plan.

(A second per-child StatisticsContext::new() in PlanSize::from_plan / enforce_distribution_relationships can get the same treatment; left as a follow-up to keep this PR focused.)

Are these changes tested?

Yes. New test ensure_distribution_shares_statistics_cache puts a leaf that counts its own statistics computations under a stack of pass-through operators, runs the distribution pass with a shared context vs a fresh-per-node context, and asserts the shared cache saves progressively more as the stack deepens. A cache that is not actually shared (e.g. reset on every node) saves nothing and fails the test — which a plan-output assertion cannot catch, since the optimized plan is identical either way.

Existing suites remain green and unchanged: datafusion --test core_integration physical_optimizer (569 passed) and datafusion-physical-plan statistics tests (96 passed).

Are there any user-facing changes?

No. Internal physical-optimizer performance only; planner output is identical.

Copilot AI lite review requested due to automatic review settings September 9, 2026 05:52
@github-actions github-actions Bot added optimizer Optimizer rules core Core DataFusion crate labels Sep 9, 2026
@zhuqi-lucas
zhuqi-lucas marked this pull request as draft September 9, 2026 05:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

It introduces a breaking change to the public ensure_distribution function signature, which is re-exported and may be used by downstream crates.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR improves physical-optimizer planning performance by reusing a single StatisticsContext (and its memoization cache) across an ensure_distribution traversal, avoiding repeated recursive statistics computation on deep/wide plans.

Changes:

  • Create one StatisticsContext for the distribution-enforcement pass and reset its cache only when a node’s plan pointer actually changes.
  • Thread &StatisticsContext through ensure_distribution and into get_repartition_requirement_status so child stats are memoized across the pass.
  • Add a deep operator-stack regression test asserting the repartition decisions remain unchanged.
File summaries
File Description
datafusion/physical-optimizer/src/ensure_requirements/mod.rs Reuses a single StatisticsContext during the bottom-up distribution pass and resets cache on actual plan-pointer rewrites.
datafusion/physical-optimizer/src/ensure_requirements/enforce_distribution.rs Threads &StatisticsContext into distribution enforcement logic to share memoized statistics across children/subtrees.
datafusion/core/tests/physical_optimizer/enforce_distribution.rs Updates call sites for the new ensure_distribution signature and adds a deep-stack test for unchanged repartition behavior.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 1356 to 1360
pub fn ensure_distribution(
dist_context: DistributionContext,
config: &ConfigOptions,
stats_ctx: &StatisticsContext,
) -> Result<Transformed<DistributionContext>> {
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion v55.0.0 (current)
       Built [  62.514s] (current)
     Parsing datafusion v55.0.0 (current)
      Parsed [   0.035s] (current)
    Building datafusion v55.0.0 (baseline)
       Built [  61.940s] (baseline)
     Parsing datafusion v55.0.0 (baseline)
      Parsed [   0.036s] (baseline)
    Checking datafusion v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.566s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 127.075s] datafusion
    Building datafusion-physical-optimizer v55.0.0 (current)
       Built [  43.016s] (current)
     Parsing datafusion-physical-optimizer v55.0.0 (current)
      Parsed [   0.023s] (current)
    Building datafusion-physical-optimizer v55.0.0 (baseline)
       Built [  43.039s] (baseline)
     Parsing datafusion-physical-optimizer v55.0.0 (baseline)
      Parsed [   0.022s] (baseline)
    Checking datafusion-physical-optimizer v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.108s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure function_parameter_count_changed: pub fn parameter count changed ---

Description:
A publicly-visible function now takes a different number of parameters.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#fn-change-arity
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/function_parameter_count_changed.ron

Failed in:
  datafusion_physical_optimizer::ensure_requirements::enforce_distribution::ensure_distribution now takes 3 parameters instead of 2, in /home/runner/work/datafusion/datafusion/datafusion/physical-optimizer/src/ensure_requirements/enforce_distribution.rs:1339
  datafusion_physical_optimizer::enforce_distribution::ensure_distribution now takes 3 parameters instead of 2, in /home/runner/work/datafusion/datafusion/datafusion/physical-optimizer/src/ensure_requirements/enforce_distribution.rs:1339

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  87.488s] datafusion-physical-optimizer

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Sep 9, 2026
@codecov-commenter

codecov-commenter commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.83%. Comparing base (4086969) to head (31bdfad).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
...er/src/ensure_requirements/enforce_distribution.rs 90.90% 1 Missing ⚠️
.../physical-optimizer/src/ensure_requirements/mod.rs 94.73% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25098      +/-   ##
==========================================
+ Coverage   81.80%   81.83%   +0.02%     
==========================================
  Files        1130     1130              
  Lines      417842   418810     +968     
  Branches   417842   418810     +968     
==========================================
+ Hits       341833   342742     +909     
+ Misses      55877    55858      -19     
- Partials    20132    20210      +78     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@zhuqi-lucas
zhuqi-lucas force-pushed the qizhu/ensure-distribution-stats-memoization branch 3 times, most recently from a6f3f81 to a2cb2c8 Compare September 9, 2026 07:35
@asolimando

Copy link
Copy Markdown
Member

Hi @zhuqi-lucas, I can confirm that your use-case fits what the cache in StatisticsContext is made for, so it sounds like a great idea.

Glad to read that you are seeing a drop in planning time of ~10% due to caching!

We can probably push this a little further: instead of just passing StatisticsContext through ensure_distribution to share the memoization cache across the pass, you could use EnsureRequirements::optimize_with_context (instead of EnsureRequirements::optimize), so that you receive a PhysicalOptimizerContext, including the StatisticsRegistry and any other statistics context useful for CBO.

StatisticsContext::new() builds an empty StatisticsRegistry, so today this rule never consults any registered provider, even when the session has one configured. I think it's worth doing in this PR, rather than a follow-up, because we can avoid changing the public ensure_distribution twice.

I have implemented something similar in #24716 for JoinSelection to showcase how I think this should be done for physical optimizer rules.

So, concretely, my suggestion is to:

  • override optimize_with_context on EnsureRequirements
  • pass context to ensure_distribution and get_repartition_requirement_status instead of stats_ctx
  • build the shared StatisticsContext from context.statistics_registry() once per pass

This would keep the caching fix from this PR, and also allow the rule to benefit from the present and future statistics context (I will be working on #21120 as my next task).

Note that there is no behavior change by default: a session without registered providers is unaffected, but it gives room for improvement without further breaking changes.

I am off this week with very limited access to a computer, but I can surely offer a review or help with a PR from next week!

Regarding the cache reset: your proposed solution seems safe to me, but in the future I'd like to make the cache more robust so consumers don't have to think about it. Either by introducing a unique id per constructed ExecutionPlan and using that as cache key, or by preventing the freeing of the deleted nodes for the lifetime of the cache (Arc clone of the node). This is only tangential to this PR, but I'd appreciate your opinion on this matter since you have the needed context already.

@zhuqi-lucas
zhuqi-lucas force-pushed the qizhu/ensure-distribution-stats-memoization branch from a2cb2c8 to 0b952ba Compare September 10, 2026 02:21
@zhuqi-lucas

zhuqi-lucas commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @asolimando — done in this PR. EnsureRequirements now overrides optimize_with_context and builds the shared StatisticsContext from context.statistics_registry() (following your #24716), so the rule both shares the memoization cache across the pass and consults registered providers; optimize delegates via ConfigOnlyContext. Default behavior is unchanged (an empty registry builds a plain context). Added ensure_distribution_uses_context_statistics_registry, which shows a scan whose tiny real stats would not warrant a round-robin repartition gets one once a registry provider reports it as large — i.e. the registry is actually threaded through the distribution pass.

On the cache reset: agreed it is a footgun to leave to consumers, but since it is tangential to this PR I have filed it as a follow-up (#25141) so the robustness can be tracked separately, and kept the safe reset-on-change here. Between your two ideas I lean toward (b) holding node Arcs in the cache, though as noted there it would widen the compute API a bit; captured both options on the issue. Happy to take that on.

…ution

get_repartition_requirement_status created a fresh StatisticsContext per
child. StatisticsContext::compute recurses the child's whole subtree and
carries a pointer-keyed memoization cache meant to be reused across a walk,
so allocating a new context per child discards it every time and a single
ensure_distribution pass recomputes shared subtree statistics O(depth) times.
A fresh context also builds an empty StatisticsRegistry, so the rule never
consulted registered statistics providers.

Override EnsureRequirements::optimize_with_context and build one shared
StatisticsContext from context.statistics_registry() per pass, threaded into
ensure_distribution and get_repartition_requirement_status. This both shares
the memoization cache and lets registered providers inform the distribution
decision (no change by default: an empty registry behaves as before). optimize
delegates to optimize_with_context via ConfigOnlyContext.

The cache is keyed by raw node pointer, so it is reset after any node whose
plan pointer actually changed (a rewrite can free a cached node and a later
allocation could reuse its address); no-op nodes keep the cache warm.

Tests:
- ensure_distribution_shares_statistics_cache: counts a leaf's statistics
  computations under a deepening pass-through stack and asserts the shared
  cache saves progressively more with depth (a non-shared cache saves nothing).
- ensure_distribution_uses_context_statistics_registry: a scan whose tiny real
  stats do not warrant round-robin is reported large by a registry provider,
  and the round-robin repartition then appears only via optimize_with_context.

No plan changes by default; physical_optimizer and statistics suites pass.

Thread the PhysicalOptimizerContext through ensure_distribution (replacing
the bare ConfigOptions) so config, the statistics registry, and future CBO
context are reachable without changing this public signature again. The
shared StatisticsContext is built once per pass from
context.statistics_registry() and threaded to get_repartition_requirement_status
so the memoization cache is shared across the whole distribution walk;
building it per call from the registry would lose that sharing. optimize
delegates to optimize_with_context via ConfigOnlyContext. Cache is reset only
when a node's plan pointer actually changed (a rewrite can free a cached node);
robustness of the pointer key tracked separately in apache#25141.

Tests:
- ensure_distribution_shares_statistics_cache: the shared cache saves
  progressively more statistics recomputation as a pass-through stack deepens.
- ensure_distribution_uses_context_statistics_registry: a scan reported large
  by a registry provider gets a round-robin repartition only via
  optimize_with_context, proving the registry is threaded through.

No default behavior change; physical_optimizer and statistics suites pass.
@zhuqi-lucas
zhuqi-lucas force-pushed the qizhu/ensure-distribution-stats-memoization branch from 0b952ba to 7389582 Compare September 10, 2026 02:40
@zhuqi-lucas
zhuqi-lucas marked this pull request as ready for review September 10, 2026 02:56
@zhuqi-lucas
zhuqi-lucas force-pushed the qizhu/ensure-distribution-stats-memoization branch from 9bef9ca to 78c17af Compare September 10, 2026 06:18
The main merge pulled in apache#24809's preserve-fetch reoptimization tests,
whose two ensure_distribution() calls still used the pre-refactor
2-arg (context, &config) form. Thread the PhysicalOptimizerContext and
a StatisticsContext through them to match the current signature.
@zhuqi-lucas
zhuqi-lucas force-pushed the qizhu/ensure-distribution-stats-memoization branch from 78c17af to 31bdfad Compare September 10, 2026 06:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change core Core DataFusion crate optimizer Optimizer rules

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants